Skip to content

Defer debugpy and psutil imports until they are actually needed - #1543

Open
Carreau wants to merge 3 commits into
ipython:mainfrom
Carreau:lazy-debug-and-psutil
Open

Carreau wants to merge 3 commits into
ipython:mainfrom
Carreau:lazy-debug-and-psutil

Conversation

@Carreau

@Carreau Carreau commented Aug 4, 2026

Copy link
Copy Markdown
Member

Both imports were paid for on every kernel startup even though most sessions never debug or ask for usage information.

  • IPythonKernel.debugger is now a lazily-created property; the debugger (and the debugpy import) is only built on the first debug request. poll_stopped_queue is scheduled at that point rather than in start().
  • debugger_class was a Type trait, which traitlets resolves — and therefore imports — as soon as the kernel is instantiated. It is replaced by a custom LazyType trait

On a local test (where I have optimisation in IPython and traitlets as well), this brings the startup time from 220ms to 170ms

@Carreau
Carreau requested a review from krassowski August 4, 2026 09:58
@Carreau
Carreau force-pushed the lazy-debug-and-psutil branch from 722618c to ac6ca05 Compare August 4, 2026 16:44
@Carreau
Carreau marked this pull request as ready for review August 5, 2026 07:39
@Carreau

Carreau commented Aug 5, 2026

Copy link
Copy Markdown
Member Author

CI failure will be taken care of by #1544

@krassowski

Copy link
Copy Markdown
Member

Both imports were paid for on every kernel startup even though most sessions never debug or ask for usage information.

They still get paid at every kernel startup after this PR. The debugger information is needed at the latest when generating kernel_info_request reply (which every frontend sends). Similar for psutil: init_connection_filejupyter_client.connect.load_connection_file_ip_defaultlocalinterfaces._load_ips_psutil.

The import time of import ipykernel does not shrink either as neither debugpy nor psutil was imported on the pure import path (I worked on it in #1223)

A benchmark showing the benefits would help here. Otherwise, there is a number of regressions here:

  1. kernel.debugger = X silently kills breakpoints (stock usage unaffected, but subclasses are)
  2. IPythonKernel(debugger_class=X) is now silently ignored
  3. _get_psutil() publishes the flag before the import (easy to fix though)
  4. Debugger init failure is sticky and silent (debug_requests will return None after the first one)

@krassowski krassowski left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As per comment above. Thank you for working on it!

@Carreau

Carreau commented Aug 8, 2026

Copy link
Copy Markdown
Member Author

Good point for subclasses, I'll see what I can do.

It's true that I tested only with #1542; that is minimal up to startup point and shut off immediately; and for import only for kernelapp.

The debugger information is needed at the latest when generating kernel_info_request reply (which every frontend sends)

Fair; I did not think about this – but I would not consider kernel_info_reply to be part of startup; I need to check but at least the startup before the first kernel_info_request is faster, I'm assuming the first kernel_info_request is sent only once the kernel is started (I'll verify), so I still think it's a (partial) win, and this should make the time to ready. But I least that's fixable separately and only affect the kernel_info_reply which for me is a separate issue – do we cache the result on disk ? Add a config to force the kernel to reply with 'debugger' always in or never and leave to to large deployment to select 'auto|true|false'

I'm also profiling locally with modified jupyter_client, traitlets and IPython (to defer more stuff from there as well); and yes some thing like psutils are otherwise still in startup path via other packages; but I can try to publish profiles even with modified versions to give an idea of what can be achieved.

@Carreau

Carreau commented Aug 8, 2026

Copy link
Copy Markdown
Member Author

I suppose another big things, is are we ok dropping debugger_class traitlet and deprecating it to at least have some way of making debugger import lazy ?

@Carreau

Carreau commented Aug 9, 2026

Copy link
Copy Markdown
Member Author

See #1545 for how I think we can handle making kerne_info_reply faster by providing a config option.

@Carreau

Carreau commented Aug 9, 2026

Copy link
Copy Markdown
Member Author

Here are the result of

IPYKERNEL_BENCHMARK_STARTUP_SHUTDOWN=1 python tools/importtime_average.py -n 20 -- python -X importtime -m ipykernel  > totlog.log && tuna totlog.log

With #1542 merged in; to stop the kernel immediately and make it measurable. Also requires IPython'm main; traitlet and jupyter_client on latest releases

ipykernel-before ipykernel-after

@Carreau

Carreau commented Aug 9, 2026

Copy link
Copy Markdown
Member Author

Also technically you should test with -f fake-connection.json file; we can land a patch in jupyter_client that does not do the _ip_default → localinterfaces._load_ips_psutil when the connection file exists (which is the case most of the time as kernel are launched by frontend that likely can provide IPs in the connection files; but again; different repository. Different optimisation even if that goal is still to avoid importing psutil

@Carreau

Carreau commented Aug 9, 2026

Copy link
Copy Markdown
Member Author

for debugger_class I can suggest something like this ipython/traitlets#972, make Type and conditional import to LazyType depending on traitlets version; or pull a minimalistic version that does not allow to check .klass and allow_none=True that i vendor here (which is what I did first):

class _LazyType(Type):  # type:ignore[type-arg]

    def instance_init(self, obj):
        """Deliberately does not resolve; see the class docstring."""
        pass

    def default(self, obj=None):
        self._resolve_classes()
        return super().default(obj)

    def validate(self, obj, value):
        self._resolve_classes()
        return super().validate(obj, value)

Though it becomes brittle as there is no more guard in MetaHasTraits.setup_class that prevent it from importing eagerly.

Constructing the debugger in `IPythonKernel.__init__` imported debugpy on
every kernel startup, roughly 50ms, even though most sessions never debug.
Create it lazily on first use instead.

`debugger_class` is what made that awkward: traitlets resolves a `Type`
trait's string default -- and therefore imports it -- as soon as the owning
HasTraits object is created. But it does so in exactly one place,
`instance_init`; `validate`, `info` and `default_value_repr` are all on
demand. A small `LazyType` subclass that skips `instance_init` and resolves on
first read or write is therefore lazy while remaining a perfectly ordinary
trait, so subclass overrides, `IPythonKernel(debugger_class=X)`, assignment
and config all keep working unchanged.

Setting `debugger_class` explicitly, by kwarg or config, builds the debugger
eagerly: the class is already imported at that point, so there is nothing left
to defer, and a bad class fails at construction rather than at the first debug
request.

Two things the lazy path has to be careful about, since the debugger is no
longer guaranteed to exist by the time `start()` runs:

* `poll_stopped_queue` used to be scheduled unconditionally in `start()`.
  It now goes through an idempotent `_ensure_stopped_queue_poll()` called from
  lazy creation, from the `debugger` setter and from `start()`, since any of
  the three can complete the set of things the poll needs. Without this,
  assigning `kernel.debugger` -- which subclasses do to substitute their own
  debugger -- would silently stop reporting breakpoints.

* The flag recording that initialisation was attempted is set only once the
  debugger has been constructed, and a failing constructor is logged before
  being re-raised. Otherwise the first debug request would get the exception
  and every later one would quietly get a None reply instead.
Setting IPYKERNEL_BENCHMARK_STARTUP_SHUTDOWN stops the event loop right
after it starts, so total process wall-clock time measures kernel
startup without needing an actual client to send a shutdown request.

This can be used in conjunction with -X importtime and profile viewer
like tuna to work on speeding up importing and starting a kernel
@Carreau
Carreau force-pushed the lazy-debug-and-psutil branch from c03f515 to 7aac75f Compare September 14, 2026 11:56
@Carreau
Carreau force-pushed the lazy-debug-and-psutil branch from ad8e22c to eeb87d1 Compare September 14, 2026 12:10
@Carreau
Carreau requested a review from krassowski September 14, 2026 12:10
@Carreau

Carreau commented Sep 14, 2026

Copy link
Copy Markdown
Member Author

this now uses a custom local LazyType traitlets, and the flag for the kernel info reply is in another PR.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants